<?php

/* vim: set expandtab tabstop=4 shiftwidth=4 softtabstop=4: */

/**
 * This file contains the following classes and helper functions:
 *
 * - __autoload()
 * - Majic
 * - Majic_Exception
 * - Majic_ErrorHandler
 * - Majic_ExceptionHandler
 * - use_helper()
 *
 * PHP version 5
 *
 * This file is part of Majic - A rapid-development web application framework
 * for PHP 5.
 *
 * Majic is free software: you can redistribute it and/or modify it under
 * the terms of the GNU Lesser General Public License as published by the Free
 * Software Foundation, either version 3 of the License, or (at your option)
 * any later version.
 *
 * Majic is distributed in the hope that it will be useful, but WITHOUT ANY
 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
 * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
 * details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with Majic. If not, see <http://www.gnu.org/licenses/>.
 *
 * @package     Majic
 * @author      Michael J. I. Jackson <mjijackson@gmail.com>
 * @copyright   2007-2008 Michael J. I. Jackson
 * @license     http://www.gnu.org/licenses/lgpl-3.0.txt GNU LGPL 3.0
 * @version     SVN: $Id: Majic.php 46 2008-11-22 20:57:51Z mjijackson $
 */

// enables automatic loading of classes without modifying the include path
function __autoload($cls)
{
    $file = '/classes/' . str_replace('_', '/', $cls) . '.php';

    // check application directory first
    if (file_exists(APP_DIR . $file)) {
        require APP_DIR . $file;
    } elseif (file_exists(MJ_DIR . $file)) {
        require MJ_DIR . $file;
    }
}

/**
 * Serves as a point of reference for the entire system. Manages application
 * configuration settings and contains some useful methods for handling errors.
 *
 * @package     Majic
 * @author      Michael J. I. Jackson <mjijackson@gmail.com>
 */
class Majic
{

    /**
     * The controller instance.
     *
     * @var     Controller
     * @access  private
     * @static
     */
    private static $controller;

    /**
     * The directory where controller files are kept.
     *
     * @var     string
     * @access  private
     * @static
     */
    private static $controller_dir;

    /**
     * The configuration variables currently in use.
     *
     * @var     array
     * @access  private
     * @static
     */
    private static $config = array();

    /**
     * A map of errors that may be used with {@link showError()}.
     *
     * @var     array
     * @access  private
     * @static
     */
    private static $errors = array(
        401 => array('401 Unauthorized', 'You are not authorized to access this page.'),
        403 => array('403 Forbidden', 'You are not authorized to access this page.'),
        404 => array('404 Not Found', 'The page you requested was not found.'),
        500 => array('500 Internal Server Error', 'There was an error processing your request.')
    );

    /**
     * Returns a reference to the current system controller.
     *
     * @return  Controller        The current system controller
     * @access  public
     * @static
     */
    public static function getController()
    {
        return self::$controller;
    }

    /**
     * Sets the directory where controller files are kept.
     *
     * @param   string      $dir        The name of the controller directory
     *                                  with no trailing slash
     * @return  void
     * @throws  Majic_Exception         If the directory is not valid
     * @access  public
     * @static
     */
    public static function setControllerDir($dir)
    {
        $dir = realpath($dir);
        if (!is_dir($dir)) {
            throw new Majic_Exception("Invalid controller directory: $dir");
        }

        self::$controller_dir = $dir;
    }

    /**
     * Gets the name of the directory where controller files are kept. Will
     * automatically use app/classes/controllers if not otherwise
     * {@link setControllerDir() specified}.
     *
     * @return  string          The name of the controller directory
     * @access  public
     * @static
     */
    public static function getControllerDir()
    {
        if (!isset(self::$controller_dir)) {
            self::$controller_dir = APP_DIR . '/classes/controllers';
        }

        return self::$controller_dir;
    }

    /**
     * Sets the value of the specified configuration registry key.
     *
     * @param   string  $key    The name of the key to set
     * @param   mixed   $value  The value of the key
     * @return  void
     * @access  public
     * @static
     */
    public static function setConfig($key, $value)
    {
        self::$config[$key] = $value;
    }

    /**
     * Gets the value of the specified configuration registry key.
     *
     * @param   mixed   $key    The name of the key to get
     * @return  mixed           The registry value
     * @access  public
     * @static
     */
    public static function getConfig($key)
    {
        return isset(self::$config[$key]) ? self::$config[$key] : null;
    }

    /**
     * Sets an error summary and message for the given error code. The error
     * code may be any scalar, but must be unique for each error.
     *
     * @param   mixed   $error      The error code
     * @param   string  $summary    The error summary
     * @param   string  $message    The error message
     * @return  void
     * @access  public
     * @static
     */
    public static function setError($error, $summary, $message)
    {
        self::$errors[$error] = array($summary, $message);
    }

    /**
     * Gets the error summary and message for the given error code.
     *
     * @param   mixed   $error      The error code
     * @return  array               An array containing the error summary and
     *                              message
     * @access  public
     * @static
     */
    public static function getError($error)
    {
        return isset(self::$errors[$error]) ? self::$errors[$error] : null;
    }

    /**
     * Displays an error page and exits. Uses the default error template. If
     * $debug is supplied, the error will also be logged.
     *
     * @param   mixed   $summary    A short summary of the error. If this is an
     *                              error code, the error message and summary will
     *                              be automatically generated and the $message
     *                              parameter will be used as $debug.
     * @param   string  $message    An explanation of the error
     * @param   string  $debug      Extra debugging information
     * @access  public
     * @return  void
     * @static
     */
    public static function showError($summary, $message = null, $debug = null)
    {
        $error = self::getError($summary);
        if ($error) {
            $debug = $message;
            $summary = $error[0];
            $message = $error[1];
        }

        $view = View::getInstance();
        echo $view->load('errors/default', array(
            'summary'   => $summary,
            'message'   => $message
        ), true);

        // log
        if ($debug) {
            if (Logger::log("$summary ($debug)", 'ERROR') === false) {
                throw new Majic_Exception('Cannot write to logs directory');
            }
        }

        exit(0);
    }

    /**
     * Determines whether or not the application client is a web client.
     *
     * @return  bool                True if the application client is a web
     *                              browser, false otherwise
     * @access  public
     * @static
     */
    public static function isWebClient()
    {
        return isset($_SERVER['SERVER_NAME']);
    }

    /**
     * Gets the PATH_INFO for this request.
     *
     * @return  string              The PATH_INFO for this request
     * @access  public
     * @static
     */
    public static function getPathInfo()
    {
        $path_info = '';
        $base = '/' . Majic::getConfig('base_file');

        // resolve the request PATH_INFO
        $keys = array('PATH_INFO', 'ORIG_PATH_INFO');
        foreach ($keys as $key) {
            $p = isset($_SERVER[$key]) ? $_SERVER[$key] : @getenv($key);
            if ($p && $p != $base) {
                $path_info = $p;
                break;
            }
        }

        return trim($path_info, "/ \t");
    }

    /**
     * Parses a URI string into its controller, method, and parameter
     * components using the configured routes. If no PATH_INFO is provided, will
     * use the default for this request. Will automatically exit if the URL
     * contains disallowed characters.
     *
     * @param   string  $path_info  (optional) The PATH_INFO string to parse
     * @return  array               An array of the parsed URI segments
     * @throws  Majic_Exception     If no controller is specified in the path
     *                              after passing through the configured routes
     * @access  public
     * @static
     */
    public static function getRoute($path_info = null)
    {
        if ($path_info == null) {
            $path_info = self::getPathInfo();
        }

        // strip any trailing extension
        $ext = Majic::getConfig('extension');
        if ($ext != '') {
            $path_info = preg_replace('/\.' . $ext . '$/', '', $path_info);
        }

        // apply custom routing rules
        $routes = Majic::getConfig('routes');
        foreach ($routes as $regexp => $route) {
            if (preg_match($regexp, $path_info)) {
                $path_info = preg_replace($regexp, $route, $path_info);
                break; // break after first match
            }
        }

        // parse out request segments
        $segments = array_filter(explode('/', $path_info));
        if (count($segments) == 0) {
            throw new Majic_Exception("No controller specified in path: $path_info");
        }
        if (count($segments) < 2) {
            $segments[] = 'index';
        }

        // check segments
        $allowed_chars = Majic::getConfig('allowed_uri_characters');
        $chars_re = '/^[' . preg_quote($allowed_chars) . ']+$/i';
        foreach ($segments as $segment) {
            if (!preg_match($chars_re, $segment)) {
                die('This URL contains disallowed characters');
            }
        }

        return array(
            'controller'    => $segments[0],
            'action'        => $segments[1],
            'params'        => array_slice($segments, 2)
        );
    }

    /**
     * Called by the base index file to boot the entire system. Should never
     * otherwise be called.
     *
     * @return  void
     * @throws  Majic_Exception         If the appropriate controller class is
     *                                  not found in the controller file
     * @access  public
     * @static
     */
    public static function run()
    {
        // resolve the requested route
        $route = self::getRoute();

        // make sure the controller file exists
        $cls = ucfirst($route['controller']) . 'Controller';
        $file = self::getControllerDir() . "/$cls.php";
        if (!is_file($file)) {
            self::showError(404, "controller=$cls");
        }

        require $file;

        // is the controller class present?
        if (class_exists($cls)) {
            self::$controller = new $cls;
            $action = $route['action'];

            // does the controller contain the requested method? we can return
            // false from inside __call to send a 404
            if (method_exists(self::$controller, $action) || method_exists(self::$controller, '__call')) {
                if (call_user_func_array(array(self::$controller, $action), $route['params']) === false) {
                    self::showError(404, "controller=$cls, action=$action");
                }
            } else {
                self::showError(404, "controller=$cls, action=$action");
            }
        } else {
            throw new Majic_Exception("Controller class \"$cls\" was not found in file \"$file\"");
        }
    }

    /**
     * A reference to the current view instance.
     *
     * @var     View
     * @access  protected
     */
    protected $view;

    /**
     * A reference to the current request instance.
     *
     * @var     Request
     * @access  protected
     */
    protected $request;

    /**
     * Constructor. Sets some class variables for convenience.
     *
     * @access  public
     */
    public function __construct()
    {
        $this->view = View::getInstance();
        $this->request = Request::getInstance();
    }

}

/**
 * The base exception class for Majic. Supports nested exceptions and pretty
 * output printing.
 *
 * @package     Majic
 * @author      Michael J. I. Jackson <mjijackson@gmail.com>
 */
class Majic_Exception extends Exception
{

    /**
     * The exception that caused this exception to be thrown (if present).
     *
     * @var     Exception
     * @access  protected
     */
    protected $cause;

    /**
     * Constructor. Supports several different signatures:
     *
     * <code>
     * Majic_Exception(string $message);
     * Majic_Exception(string $message, int $code);
     * Majic_Exception(string $message, Exception $cause);
     * Majic_Exception(string $message, Exception $cause, int $code);
     * </code>
     *
     * @param   string          $message    The exception's message
     * @param   int|Exception   $p2         (optional) The exception's cause or
     *                                      an error code
     * @param   int             $p3         (optional) The exception's error
     *                                      code
     * @access  public
     */
    public function __construct($message, $p2 = null, $p3 = null)
    {
        if (!is_null($p2)) {
            if (is_int($p2)) {
                $code = $p2;
                $this->cause = null;
            } elseif ($p2 instanceof Exception) {
                $code = $p3;
                $this->cause = $p2;
            } else {
                throw new Majic_Exception('Exception cause must be an Exception');
            }
        } else {
            $code = null;
            $this->cause = null;
        }

        parent::__construct($message, $code);
    }

    /**
     * Gets an array of backtrace information about this exception.
     *
     * @return  array           This exception's backtrace
     * @access  public
     * @see     Majic_ExceptionHandler::getTraceSafe()
     */
    public function getTraceSafe()
    {
        return Majic_ExceptionHandler::getTraceSafe($this);
    }

    /**
     * Gets the exception that caused this exception to be thrown.
     *
     * @return  Exception       The exception that caused this exception
     * @access  public
     */
    public function getCause()
    {
        return $this->cause;
    }

    /**
     * Gets an array of all causes that triggered this exception. This function
     * must be public on all child classes.
     *
     * @return  array           An array of information about all causes
     * @access  public
     */
    public function getCauseSummaries()
    {
        $causes = array(array(
            'class'     => get_class($this),
            'message'   => $this->getMessage(),
            'file'      => $this->getFile(),
            'line'      => $this->getLine()
        ));

        if ($this->cause instanceof Majic_Exception) {
            $causes = array_merge($causes, $this->cause->getCauseSummaries());
        } elseif ($this->cause instanceof Exception) {
            $causes[] = array(
                'class'     => get_class($this->cause),
                'message'   => $this->cause->getMessage(),
                'file'      => $this->cause->getFile(),
                'line'      => $this->cause->getLine()
            );
        }

        return $causes;
    }

}

/**
 * Global Majic error handler class.
 *
 * @package     Majic
 * @author      Michael J. I. Jackson <mjijackson@gmail.com>
 */
class Majic_ErrorHandler
{

    /**
     * Map of error level constants to short summaries.
     *
     * @var     array
     * @access  private
     * @static
     */
    private static $levels = array(
        E_ERROR             => 'Error',
        E_WARNING           => 'Warning',
        E_PARSE             => 'Parse Error',
        E_NOTICE            => 'Notice',
        E_CORE_ERROR        => 'Core Error',
        E_CORE_WARNING      => 'Core Warning',
        E_COMPILE_ERROR     => 'Compile Error',
        E_COMPILE_WARNING   => 'Compile Warning',
        E_USER_ERROR        => 'Error',
        E_USER_WARNING      => 'Warning',
        E_USER_NOTICE       => 'Notice'
    );

    /**
     * Determines whether or not the error trace will be displayed.
     *
     * @var     bool
     * @access  protected
     * @static
     */
    protected static $display_trace = true;

    /**
     * Gets/sets whether or not error traces will be displayed. If the $bool
     * parameter is supplied, will be set. The old value will be returned.
     *
     * @param   bool    $bool       True to display error traces, false to hide
     *                              hide them
     * @return  bool                The old value
     * @access  public
     * @static
     */
    public static function displayTrace($bool = null)
    {
        $old = self::$display_trace;
        if (is_bool($bool)) {
            self::$display_trace = $bool;
        }

        return $old;
    }

    /**
     * Converts a function argument to an HTML string representation of the
     * same.
     *
     * @param   mixed   $arg        The argument to convert
     * @param   int     $truncate   The limit at which string arguments should
     *                              be truncated
     * @return  string              The string representation
     * @access  public
     * @static
     */
    public static function argToHtml($arg, $truncate = 16)
    {
        if (is_null($arg)) {
            return 'NULL';
        }
        if (is_object($arg)) {
            return 'Object(' . get_class($arg) . ')';
        }
        if (is_bool($arg)) {
            return $arg ? 'true' : 'false';
        }
        if (is_int($arg) || is_double($arg)) {
            return $arg;
        }
        if (is_array($arg)) {
            $args = array();
            foreach ($arg as $a) {
                $args[] = self::argToHtml($a, $truncate);
            }
            return 'Array(' . implode(', ', $args) . ')';
        }
        if (is_resource($arg)) {
            $r_type = get_resource_type($arg);
            return "$arg($r_type)";
        }

        $arg = (string) $arg;
        $arg = htmlspecialchars($arg);
        if (strlen($arg) > $truncate) {
            $arg = substr($arg, 0, $truncate) . '&hellip;';
        }

        return "'$arg'";
    }

    /**
     * Gets a summary message for an error.
     *
     * @param   int     $level      The level of the error
     * @return  string              The summary message
     * @access  public
     * @static
     */
    public static function errorSummary($level)
    {
        return isset(self::$levels[$level]) ? self::$levels[$level] : self::$levels[E_ERROR];
    }

    /**
     * Converts an error/exception trace to more readable HTML.
     *
     * @param   array       $trace      The error/exception trace
     * @return  string                  An HTML representation of the trace
     * @access  protected
     * @static
     */
    protected static function traceToHtml($trace)
    {
        $html = '';

        foreach ($trace as $i => $t) {
            if ($i) {
                $html .= "^<br>\n";
            }
            $args = array();
            if (!empty($t['args'])) {
                foreach ($t['args'] as $arg) {
                    $args[] = self::argToHtml($arg);
                }
            }

            $signature = empty($t['class']) ? '' : $t['class'] . $t['type'];
            $html .= '<b>' . $signature . $t['function'] . '</b>(' . implode(', ', $args) . ")<br>\n"
                . " file: " . (empty($t['file']) ? 'unknown' : $t['file']) . "<br>\n"
                . " line: " . (empty($t['line']) ? 'unknown' : $t['line']) . "<br>\n";
        }

        return $html;
    }

    /**
     * Converts an error/exception trace to a textual representation of the
     * same.
     *
     * @param   array       $trace      The error/exception trace
     * @return  string                  A textual representation of the trace
     * @access  protected
     * @static
     */
    protected static function traceToText($trace)
    {
        $html = self::traceToHtml($trace);
        return htmlspecialchars_decode(trim(strip_tags($html)));
    }

    /**
     * Converts the error information to an HTML representation of the same.
     *
     * @param   int     $level      The level of the error
     * @param   string  $message    The error message
     * @param   string  $file       The file that the error originated in
     * @param   int     $line       The line the error is on
     * @return  string              The error as HTML
     * @access  public
     * @static
     */
    public static function toHtml($level, $message, $file, $line)
    {
        $summary = self::errorSummary($level);

        $html = "<div style=\"font:12px sans-serif;margin:40px;padding:12px 20px;color:#333;border:1px solid #999;\">\n"
            . "<h1 style=\"font-size:14px;color:#a00;\">$summary</h1>\n";

        $html .= '<b>' . htmlspecialchars($message) . "</b><br>\n"
            . " file: $file<br>\n"
            . " line: $line<br>\n";

        if (self::$display_trace) {
            $trace = debug_backtrace();
            array_shift($trace); // shift off call to handle()

            $html .= "\n<h1 style=\"font-size:14px;color:#a00;\">Backtrace</h1>\n"
                . self::traceToHtml($trace);
        }

        return $html . "</div>\n";
    }

    /**
     * Converts the error information to a textual representation of the same.
     *
     * @param   int     $level      The level of the error
     * @param   string  $message    The error message
     * @param   string  $file       The file that the error originated in
     * @param   int     $line       The line the error is on
     * @return  string              The error as text
     * @access  public
     * @static
     */
    public static function toText($level, $message, $file, $line)
    {
        $html = self::toHtml($level, $message, $file, $line);
        return htmlspecialchars_decode(trim(strip_tags($html)));
    }

    /**
     * Error handler callback function.
     *
     * @param   int     $level      The level of the error
     * @param   string  $message    The error message
     * @param   string  $file       The file that the error originated in
     * @param   int     $line       The line the error is on
     * @return  void
     * @access  public
     * @static
     */
    public static function handle($level, $message, $file, $line)
    {
        if (!($level & error_reporting())) {
            return; // don't report
        }

        // print
        if (ini_get('display_errors') == 'On') {
            if (Majic::isWebClient()) {
                echo self::toHtml($level, $message, $file, $line);
            } else {
                echo self::toText($level, $message, $file, $line);
            }
        }

        // log
        $summary = self::errorSummary($level);
        Logger::log("$summary: $message (file: $file, line: $line)", 'ERROR');
    }

}

// set default error handler
set_error_handler(array('Majic_ErrorHandler', 'handle'));

/**
 * Handles uncaught exceptions.
 *
 * @package     Majic
 * @author      Michael Jackson <mjijackson@gmail.com>
 */
class Majic_ExceptionHandler extends Majic_ErrorHandler
{

    /**
     * Gets the stack trace of an exception. If the exception's stack trace
     * is empty, will return the results of debug_backtrace().
     *
     * @param   Exception   $e      The exception to get the trace for
     * @return  array               The exception trace
     * @access  public
     * @static
     */
    public static function getTraceSafe(Exception $e)
    {
        $trace = $e->getTrace();
        if (empty($trace)) {
            $backtrace = debug_backtrace();
            $trace = array($backtrace[count($backtrace) - 1]);
        }

        return $trace;
    }

    /**
     * Converts an exception to an HTML representation of the same.
     *
     * @param   Exception   $e      The exception to print
     * @return  string              An HTML representation of the exception
     * @access  public
     * @static
     */
    public static function toHtml(Exception $e)
    {
        $html = "<div style=\"font:12px sans-serif;margin:40px;padding:12px 20px;color:#333;border:1px solid #999;\">\n"
            . "<h1 style=\"font-size:14px;color:#a00;\">Uncaught Exception</h1>\n";

        if ($e instanceof Majic_Exception) {
            $causes = $e->getCauseSummaries();
        } else {
            $causes = array(array(
                'class'     => get_class($e),
                'message'   => $e->getMessage(),
                'file'      => $e->getFile(),
                'line'      => $e->getLine()
            ));
        }
        foreach ($causes as $i => $cause) {
            if ($i) {
                $html .= "^<br>\n";
            }
            $html .= "<b>" . $cause['class'] . '</b>: '
                . str_replace("\n", "<br>\n", htmlspecialchars($cause['message']))
                . "<br>\n"
                . " file: " . $cause['file'] . "<br>\n"
                . " line: " . $cause['line'] . "<br>\n";
        }

        $trace = self::getTraceSafe($e);

        $html .= "\n<h1 style=\"font-size:14px;color:#a00;\">Backtrace</h1>\n"
            . self::traceToHtml($trace);

        return $html . "</div>\n";
    }

    /**
     * Converts an exception to a textual representation of the same.
     *
     * @param   Exception   $e      The exception to print
     * @return  string              A textual representation of the exception
     * @access  public
     * @static
     */
    public static function toText(Exception $e)
    {
        $html = self::toHtml($e);
        return htmlspecialchars_decode(trim(strip_tags($html)));
    }

    /**
     * Exception handler callback function. Handles all uncaught exceptions.
     *
     * @param   Exception   $e      The exception that was not caught
     * @return  void
     * @access  public
     * @static
     */
    public static function handle(Exception $e)
    {
        // print
        if (Majic::isWebClient()) {
            echo self::toHtml($e);
        } else {
            echo self::toText($e);
        }

        // log
        $message = $e->getMessage();
        $file = $e->getFile();
        $line = $e->getLine();
        Logger::log("Exception: $message (file: $file, line: $line)", 'ERROR');

        // exit on uncaught exceptions
        exit(0);
    }

}

// set default exception handler
set_exception_handler(array('Majic_ExceptionHandler', 'handle'));

/**
 * Includes a helper file. If the helper is not found, an exception will be
 * thrown. May be called in several different ways:
 *
 * <code>
 * use_helper('url')
 * </code>
 *
 * Includes the 'url' helper.
 *
 * <code>
 * use_helper(array('url', 'array'))
 * </code>
 *
 * Includes the 'url' and 'array' helpers.
 *
 * <code>
 * use_helper('url', 'array')
 * </code>
 *
 * Includes the 'url' and 'array' helpers.
 *
 * @param   mixed  $helper_name     The name of the helper, an array of helper
 *                                  names, or a list of helper names (see notes)
 * @return  void
 * @throws  Majic_Exception         If the helper can't be found
 * @access  public
 */
function use_helper($helper)
{
    static $loaded_helpers = array();
    $helpers = is_array($helper) ? $helper : func_get_args();

    foreach ($helpers as $h) {
        $h = strtolower($h);

        if (in_array($h, $loaded_helpers)) {
            continue; // don't load same helper twice
        }

        $file = "/helpers/$h.php";
        if (file_exists(APP_DIR . $file)) {
            require_once APP_DIR . $file;
        } else if (file_exists(MJ_DIR . $file)) {
            require_once MJ_DIR . $file;
        } else {
            throw new Majic_Exception("Helper \"$h\" cannot be found");
        }

        $loaded_helpers[] = $h;
    }
}

?>
